fix(android): Invalid window token from SCVH teardown race (round 2)#3
Conversation
Verification on Pixel_9 emulator (Android 14+, hidden-API blocks SCVH reflection)Built the example app from the worktree branch, installed on maestro suite — 9/9 passed in 3m 31s
argent manual stress
logcatThis emulator turns out to be exactly the reflection-blocked case the latch is designed for: First SCVH detach in each process trips the latch → every subsequent attach uses the legacy Filtered 🤖 Generated with Claude Code |
Follow-up: removed
|
Round 3: zero-crash guarantee + snapshot-race fix preservedTwo further commits to close the remaining holes after the latch-removal regression. 1855d2cAdds two more layers: 1.
The deferred release uses All 6 2. API 30 On Android 11 the SCVH path through Scoped to API 30 only — Android 12+ dropped the permission check for SCVH's Verified on Favvy_Android_30 (API 30) emulatorThe exact crash reproduced on a fresh cold-booted Favvy_Android_30 (Pixel 4 / Android 11): After the fix, same emulator, same operation: 4 home/appSwitch cycles + 8 rapid enable/disable toggles → app PID stable, Summary of the layered fix
The SCVH SurfaceFlinger-direct alpha toggle stays enabled on every device that supports it (Android 12+). The snapshot race fix runs unmodified. 🤖 Generated with Claude Code |
Round 4: real blur on Android 11 (API 30) — not a flat tintAfter scoping out the SCVH crash on Android 11 in the previous round, the BLUR mode on those devices fell through to 36af299Reuse the captured bitmap as a poor-man's blur on API < 31 instead of discarding it. Capture at 1/12 in each dimension (1/144 pixels) — small enough that } else {
val bitmap = captureViewBitmap(source, scale = FALLBACK_BLUR_CAPTURE_SCALE)
if (bitmap != null) {
target.setImageBitmap(bitmap)
val tint = style.tintColor()
target.foreground = if (tint != Color.TRANSPARENT) ColorDrawable(tint) else null
target.setBackgroundColor(Color.TRANSPARENT)
} else {
// Source view had no laid-out size yet (rare; first frame).
// Original flat-tint fallback keeps privacy without the smudge.
target.setImageDrawable(null)
target.foreground = null
target.setBackgroundColor(style.fallbackColor())
}
}Verified on Favvy_Android_30 (API 30) emulatorMode 5 rapid Home / app-switcher cycles on top of the BLUR mode — app PID stable, Status summary
|
… + fix Android 11 BLUR fallback
Production crash from downstream apps' Play Console (NEAR Mobile and others):
java.lang.IllegalArgumentException: Invalid window token (never added or removed already)
at android.view.WindowlessWindowManager.relayout
at android.view.ViewRootImpl.relayoutWindow / performTraversals
at android.view.Choreographer.doFrame
The SCVH path introduced in v0.1.3 (snapshot-race fix) creates a
`WindowlessWindowManager`-backed window through `SurfaceControlViewHost`.
Teardown is racy: `host.release()` removes the WWM token via an
asynchronously-dispatched `doDie()` (`MSG_DIE`), and any
`TraversalRunnable` already in the SCVH ViewRootImpl's Choreographer
queue can fire after the token is removed, throwing the crash from
`Looper.loop` — outside any try/catch.
v0.1.5 added reflective `unscheduleScvhTraversals` before release,
which cancels queued runnables when reflection is available. Android
14+ hidden-API enforcement blocks the probe on a growing share of
devices, leaving the crash unfixed there.
This commit closes the crash class deterministically while preserving
the SurfaceFlinger-direct alpha toggle that wins the Home-press
snapshot race on every device that supports SCVH. Layered defences in
`detachCoverView` + `tryAttachCoverViaScvh`:
- `FreezableFrameLayout` cover-content root whose `requestLayout()`
and `invalidate()` no-op while a `frozen` flag is set. Set first
in `detachCoverView`; from that point no `requestLayout` reaches
`ViewRootImpl.scheduleTraversals`, so no new TraversalRunnable can
be queued during teardown.
- Snapshot-then-null-out of shared state at the top of
`detachCoverView`, plus a `coverDetaching` re-entrance guard. Any
synchronous re-entrant call (animation cancel, dispatchDetached)
sees cleared fields and bails before double-removing or
double-releasing.
- Cancel SCVH traversals BEFORE `removeView` via
`unscheduleScvhTraversals` (best-effort; latches off on permanent
reflection failure).
- `WindowManager.removeViewImmediate` so `dispatchDetachedFromWindow`
runs inline while the freeze is active. Falls back to async
`removeView` on OEM impls that reject immediate removal in
transitional states.
- `setCoverVisibility` validity-checks `view.windowToken != null`
before `updateViewLayout` — same WMS/WWM path that throws when the
token is gone.
- `deferredReleaseScvh` schedules `safeReleaseScvh` to run AFTER the
next Choreographer frame's traversal callbacks complete. Uses
`Choreographer.postFrameCallback` (animation phase) → nested
`mainHandler.post`. `ViewRootImpl.mTraversalScheduled` guarantees at
most one queued TraversalRunnable per ViewRootImpl; a single frame's
wait drains the queue. Any pre-queued runnable fires with the WWM
token still valid; release happens after, removing the token only
when nothing is left to relayout. Wired into all 6 release call
sites (5 in `tryAttachCoverViaScvh` recovery branches +
`detachCoverView`).
- API 30 + `INTERNAL_SYSTEM_WINDOW` pre-check. On Android 11,
`WindowManagerService.addWindow` enforces this signature-level
permission for the SCVH path. `host.setView` throws
`SecurityException` AFTER `ViewRootImpl.setView` has already called
`requestLayout()`, queuing a TraversalRunnable that fires the same
vsync and crashes because the token was never registered with the
WWM. The deferred release can't help — the runnable fires in the
same vsync's TRAVERSAL phase, before our queued Handler message.
Skipping SCVH entirely when the permission isn't granted is the
only safe path. Scoped to API 30 only; Android 12+ dropped the
check, so SCVH and the snapshot-race fix run unmodified on every
modern device.
Also fixes `CoverBlurRenderer` on API < 31: `RenderEffect` doesn't
exist, and the previous fallback dropped the captured bitmap and
painted a flat ~80% white tint, leaving underlying app content fully
readable through the cover (broken privacy on every Android 11
host). Now captures at 1/12 in each dimension (1/144 pixels) — small
enough that ImageView's bilinear filter at draw time produces a
frosted-glass smudge — and layers the style tint as foreground,
matching the visual contract of the API >= S path.
Verified on Favvy_Android_30 (API 30, reflection-blocked,
permission-restricted SCVH) and Pixel_9 (API 34+, reflection-blocked,
SCVH-friendly): no `IllegalArgumentException: Invalid window token`
in `logcat *:E` across 9/9 maestro flows + rapid Home/app-switcher
cycles + rapid enable/disable toggles. App PID stable. SCVH fast
alpha (`broadcast: fast scvh=true dt=0ms`) persists across detaches
on devices that support it. Android 11 BLUR mode shows real smudged
content with style tint in recents-thumbnail view.
36af299 to
f30016c
Compare
Summary
Layered defences against
IllegalArgumentException: Invalid window token (never added or removed already)thrown fromWindowlessWindowManager.relayoutviaViewRootImpl.performTraversals→Choreographer.doFrame. v0.1.5 (e17bfa2) added a reflective traversal cancel beforehost.release(); this PR closes the remaining gaps that show up as production crashes (NEAR Mobile, Play Console).Failure modes still in play after v0.1.5
unscheduleTraversals()throws, latchesscvhReflectionDisabled = true, and every later detach proceeds with no cancellation at all.host.release()removes the WWM token inside an asynchronously-dispatcheddoDie()(viaViewRootImpl.die(false)→MSG_DIE). Between our cancel anddoDie()running, framework code (dispatchDetachedFromWindow, animation cleanup, focus changes) can callrequestLayout()on the SCVH content tree and schedule a fresh Choreographer callback. IfdoDie()then wins, the queued callback fires on a dead window and crashes fromLooper.loop— outside any try/catch.Defences added in HybridCover.kt
FreezableFrameLayout— content root whoserequestLayout()/invalidate()no-op whilefrozenis set. Set first thing indetachCoverView; from that point no layout request reachesViewRootImpl.scheduleTraversalsand no new Choreographer callback gets queued during teardown.coverDetachingre-entrance guard + snapshot-then-null-out of shared state. Any synchronous re-entrant call (animation cancel listener, detach dispatch) sees cleared fields and bails before double-removing or double-releasing.unscheduleScvhTraversalscalled twice — once indetachCoverViewbeforeremoveView, again insidesafeReleaseScvhbeforehost.release(). Defence in depth against traversals queued by the detach dispatch.removeViewImmediatesodispatchDetachedFromWindowruns inline while the freeze is active; fall back to asyncremoveViewon OEM impls (some Samsung / MIUI builds) that reject immediate removal in transitional states.view.windowTokenbeforeupdateViewLayoutinsetCoverVisibility— same WMS/WWM path that throws once the token is gone.scvhDisabled = truewhenunscheduleScvhTraversalspermanently fails. Without a working unschedule there's no safe SCVH teardown path on this device; future attaches use the legacy WindowManager window (noWindowlessWindowManager, no race). We lose the SurfaceFlinger-direct alpha toggle on that device but eliminate the crash surface entirely.Test plan
./gradlew :react-native-cover:compileDebugKotlinfrom the example app — builds clean (only pre-existingval defaultDisplay: Display!deprecation warnings).Looper.loop.scvhDisabled = true, subsequent attaches use legacy path.🤖 Generated with Claude Code